Repository settings: build secrets and deploy keys !182

merged merged by cmc on 2026-10-16 03:33 UTC · krz/gitbay-ios:repo-secrets-deploy-keys into main

6 files changed, +615 −0

Layout: unified · split

gitbay/ContentView.swift +4
@@ -327,6 +327,10 @@ private struct RouteDestinations: ViewModifier {
327327 WebhookListView(client: client, repo: repo)
328328 case .webhookDeliveries(let repo):
329329 WebhookDeliveriesView(client: client, repo: repo)
330 case .buildSecrets(let repo):
331 BuildSecretsView(client: client, repo: repo)
332 case .deployKeys(let repo):
333 DeployKeysView(client: client, repo: repo)
330334 case .wiki(let repo):
331335 WikiView(client: client, repo: repo)
332336 case .wikiPage(let repo, let page):
gitbay/Repos/RepoCredentials.swift added +145
@@ -0,0 +1,145 @@
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}
gitbay/Views/Repos/RepoCredentialViews.swift added +294
@@ -0,0 +1,294 @@
1import SwiftUI
2
3/// A repository's build secrets: their names, a way to set or replace
4/// one, and removal. Values are never shown, here or anywhere.
5struct BuildSecretsView: View {
6
7 @State private var model: BuildSecretsViewModel
8 @State private var adding = false
9 @State private var removing: String?
10
11 init(client: GitbayClient, repo: String) {
12 _model = State(initialValue: BuildSecretsViewModel(client: client, repoPath: repo))
13 }
14
15 var body: some View {
16 List {
17 if let error = model.actionError, !adding {
18 Section { GBNotice(error, .gbWarn) }
19 }
20 Section {
21 ForEach(model.state.value ?? [], id: \.self) { name in
22 Text(name)
23 .font(.gbMono(.subheadline))
24 .swipeActions {
25 Button("Remove", role: .destructive) { removing = name }
26 .disabled(model.working)
27 }
28 }
29 } footer: {
30 if model.state.value != nil {
31 Text("Builds read each secret as an environment variable. Setting a name again replaces its value.")
32 }
33 }
34 }
35 .overlay { LoadStateOverlay(state: model.state) }
36 .navigationTitle("Build Secrets")
37 .navigationBarTitleDisplayMode(.inline)
38 .toolbar {
39 ToolbarItem(placement: .topBarTrailing) {
40 Button { adding = true } label: { Image(systemName: "plus") }
41 .accessibilityLabel("Set Secret")
42 .disabled(model.working)
43 .accessibilityIdentifier("secret-add-button")
44 }
45 }
46 .sheet(isPresented: $adding) {
47 SecretSetSheet(model: model) { adding = false }
48 }
49 .confirmationDialog(
50 "Remove \(removing ?? "")? Builds that read it will no longer find it.",
51 isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
52 titleVisibility: .visible
53 ) {
54 Button("Remove", role: .destructive) {
55 if let name = removing { Task { await model.remove(name) } }
56 removing = nil
57 }
58 Button("Cancel", role: .cancel) {}
59 }
60 .task { await model.load() }
61 .refreshable { await model.load() }
62 }
63}
64
65private struct SecretSetSheet: View {
66 let model: BuildSecretsViewModel
67 let onDone: () -> Void
68
69 @State private var name = ""
70 @State private var value = ""
71 @State private var multiLine = false
72 /// Errors shown here are this sheet's own, not an earlier list action's.
73 @State private var attempted = false
74
75 var body: some View {
76 NavigationStack {
77 Form {
78 Section {
79 TextField("NAME", text: $name)
80 .font(.gbMono(.body))
81 .autocorrectionDisabled()
82 .textInputAutocapitalization(.characters)
83 .accessibilityIdentifier("secret-name")
84 if multiLine {
85 TextEditor(text: $value)
86 .frame(minHeight: 120)
87 .font(.gbMono(.caption))
88 .autocorrectionDisabled()
89 .textInputAutocapitalization(.never)
90 .accessibilityIdentifier("secret-value-multiline")
91 } else {
92 SecureField("Value", text: $value)
93 .autocorrectionDisabled()
94 .textInputAutocapitalization(.never)
95 .accessibilityIdentifier("secret-value")
96 }
97 Toggle("Multi-line value", isOn: $multiLine)
98 } footer: {
99 Text(multiLine
100 ? "Shown while you type, for a value with line breaks such as a private key. It is sent once and cannot be read back."
101 : "Uppercase letters, digits and underscores. The value is sent once and cannot be read back.")
102 }
103 if attempted, let error = model.actionError {
104 Section { GBNotice(error, .gbWarn) }
105 }
106 }
107 .navigationTitle("Set Secret")
108 .navigationBarTitleDisplayMode(.inline)
109 .toolbar {
110 ToolbarItem(placement: .cancellationAction) {
111 Button("Cancel") { onDone() }.disabled(model.working)
112 }
113 ToolbarItem(placement: .confirmationAction) {
114 if model.working {
115 ProgressView()
116 } else {
117 Button("Set") {
118 Task {
119 attempted = true
120 guard await model.set(name: name, value: value) else { return }
121 // A secure field that still holds text as the sheet
122 // goes makes iOS offer to save it as a password.
123 value = ""
124 await Task.yield()
125 onDone()
126 }
127 }
128 .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || value.isEmpty)
129 .accessibilityIdentifier("secret-submit")
130 }
131 }
132 }
133 .interactiveDismissDisabled(model.working)
134 }
135 }
136}
137
138/// A repository's deploy keys: keys bound to it alone, read-only unless
139/// they may push, with when each was used and when it expires.
140struct DeployKeysView: View {
141
142 @State private var model: DeployKeysViewModel
143 @State private var adding = false
144 @State private var removing: DeployKey?
145
146 init(client: GitbayClient, repo: String) {
147 _model = State(initialValue: DeployKeysViewModel(client: client, repoPath: repo))
148 }
149
150 var body: some View {
151 List {
152 if let error = model.actionError, !adding {
153 Section { GBNotice(error, .gbWarn) }
154 }
155 Section {
156 ForEach(model.state.value ?? []) { key in
157 VStack(alignment: .leading, spacing: 3) {
158 if !key.label.isEmpty {
159 Text(key.label).font(.gbSans(.subheadline))
160 }
161 Text(key.fingerprint)
162 .font(.gbMono(.caption))
163 .lineLimit(1)
164 .truncationMode(.middle)
165 HStack(spacing: 6) {
166 GBChip(key.canPush ? "read-write" : "read-only", key.canPush ? Color.gbWarn : Color.secondary)
167 dates(key)
168 }
169 }
170 .padding(.vertical, 2)
171 .accessibilityElement(children: .combine)
172 .swipeActions {
173 Button("Remove", role: .destructive) { removing = key }
174 .disabled(model.working)
175 }
176 }
177 } footer: {
178 if model.state.value != nil {
179 Text("A deploy key reaches this repository and nothing else. A read-write key can push.")
180 }
181 }
182 }
183 .overlay { LoadStateOverlay(state: model.state) }
184 .navigationTitle("Deploy Keys")
185 .navigationBarTitleDisplayMode(.inline)
186 .toolbar {
187 ToolbarItem(placement: .topBarTrailing) {
188 Button { adding = true } label: { Image(systemName: "plus") }
189 .accessibilityLabel("Add Deploy Key")
190 .disabled(model.working)
191 .accessibilityIdentifier("deploy-key-add-button")
192 }
193 }
194 .sheet(isPresented: $adding) {
195 DeployKeyAddSheet(model: model) { adding = false }
196 }
197 .confirmationDialog(
198 "Remove the deploy key \(removing.map { $0.label.isEmpty ? $0.fingerprint : $0.label } ?? "")?",
199 isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
200 titleVisibility: .visible
201 ) {
202 Button("Remove", role: .destructive) {
203 if let key = removing { Task { await model.remove(key) } }
204 removing = nil
205 }
206 Button("Cancel", role: .cancel) {}
207 }
208 .task { await model.load() }
209 .refreshable { await model.load() }
210 }
211
212 private func dates(_ key: DeployKey) -> some View {
213 HStack(spacing: 4) {
214 if let used = key.lastUsedAt {
215 Text("used \(used.formatted(.relative(presentation: .named)))")
216 } else {
217 Text("never used")
218 }
219 if let expires = key.expiresAt {
220 Text("·")
221 if expires <= .now {
222 Text("expired \(expires.formatted(date: .abbreviated, time: .omitted))")
223 .foregroundStyle(Color.gbBad)
224 } else {
225 Text("expires \(expires.formatted(date: .abbreviated, time: .omitted))")
226 }
227 }
228 }
229 .font(.gbSans(.caption2))
230 .foregroundStyle(.secondary)
231 }
232}
233
234private struct DeployKeyAddSheet: View {
235 let model: DeployKeysViewModel
236 let onDone: () -> Void
237
238 @State private var text = ""
239 @State private var canPush = false
240 @State private var ttl: String?
241 @State private var attempted = false
242
243 var body: some View {
244 NavigationStack {
245 Form {
246 Section {
247 TextEditor(text: $text)
248 .frame(minHeight: 120)
249 .font(.gbMono(.caption))
250 .autocorrectionDisabled()
251 .textInputAutocapitalization(.never)
252 .accessibilityIdentifier("deploy-key-text")
253 } footer: {
254 Text("Paste the public key, as in authorized_keys. Its comment becomes the label.")
255 }
256 Section {
257 Toggle("Allow pushes", isOn: $canPush)
258 .accessibilityIdentifier("deploy-key-rw")
259 Picker("Expires", selection: $ttl) {
260 Text("Never").tag(String?.none)
261 Text("In 30 days").tag(String?.some("30d"))
262 Text("In 90 days").tag(String?.some("90d"))
263 Text("In a year").tag(String?.some("365d"))
264 }
265 }
266 if attempted, let error = model.actionError {
267 Section { GBNotice(error, .gbWarn) }
268 }
269 }
270 .navigationTitle("Add Deploy Key")
271 .navigationBarTitleDisplayMode(.inline)
272 .toolbar {
273 ToolbarItem(placement: .cancellationAction) {
274 Button("Cancel") { onDone() }.disabled(model.working)
275 }
276 ToolbarItem(placement: .confirmationAction) {
277 if model.working {
278 ProgressView()
279 } else {
280 Button("Add") {
281 Task {
282 attempted = true
283 if await model.add(publicKey: text, canPush: canPush, ttl: ttl) { onDone() }
284 }
285 }
286 .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
287 .accessibilityIdentifier("deploy-key-submit")
288 }
289 }
290 }
291 .interactiveDismissDisabled(model.working)
292 }
293 }
294}
gitbay/Views/Repos/RepoRoute.swift +2
@@ -25,6 +25,8 @@ nonisolated enum RepoRoute: Hashable {
2525 case queryResults(name: String)
2626 case webhooks(repo: String)
2727 case webhookDeliveries(repo: String)
28 case buildSecrets(repo: String)
29 case deployKeys(repo: String)
2830 case profile(String)
2931 case account
3032 case tokens
gitbay/Views/Repos/RepoSettingsView.swift +10
@@ -551,6 +551,16 @@ struct RepoSettingsView: View {
551551 .font(.gbSans(.subheadline))
552552 }
553553 .accessibilityIdentifier("settings-webhooks-link")
554 NavigationLink(value: RepoRoute.buildSecrets(repo: model.repoPath)) {
555 Label("Build secrets", systemImage: "key.horizontal")
556 .font(.gbSans(.subheadline))
557 }
558 .accessibilityIdentifier("settings-secrets-link")
559 NavigationLink(value: RepoRoute.deployKeys(repo: model.repoPath)) {
560 Label("Deploy keys", systemImage: "key")
561 .font(.gbSans(.subheadline))
562 }
563 .accessibilityIdentifier("settings-deploy-keys-link")
554564 }
555565 }
556566
gitbayTests/RepoCredentialTests.swift added +160
@@ -0,0 +1,160 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func body(_ seen: StubProtocol.Seen) throws -> [String: Any] {
16 try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
17}
18
19private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
20private let secretsJSON = #"{"protocol_version":1,"data":["DEPLOY_TOKEN","NPM_TOKEN"],"exit_code":0}"#
21private let deployKeysJSON = """
22 {"protocol_version":1,"data":[\
23 {"fingerprint":"SHA256:abc","algo":"ssh-ed25519","mode":"rw","label":"ci@box","last_used_at":"2026-10-10T08:00:00.000Z"},\
24 {"fingerprint":"SHA256:def","algo":"ssh-ed25519","mode":"ro","label":"","expires_at":"2026-11-10T08:00:00Z"}\
25 ],"exit_code":0}
26 """
27
28@MainActor
29struct BuildSecretsViewModelTests {
30
31 @Test func listsSecretNames() async throws {
32 let (client, stub) = try makeClient()
33 stub.enqueue(.init(status: 200, json: secretsJSON))
34 let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay")
35 await model.load()
36 #expect(model.state.value == ["DEPLOY_TOKEN", "NPM_TOKEN"])
37 #expect(stub.seen.first?.url.query() == "argv=repo&argv=secret&argv=list&argv=krz/gitbay")
38 }
39
40 @Test func noSecretsIsAnEmptyState() async throws {
41 let (client, stub) = try makeClient()
42 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":null,"exit_code":0}"#))
43 let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay")
44 await model.load()
45 guard case .empty = model.state else {
46 Issue.record("expected .empty, got \(model.state)")
47 return
48 }
49 }
50
51 @Test func setSendsTheValueOnStdinNeverInArgv() async throws {
52 let (client, stub) = try makeClient()
53 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"secret":"DEPLOY_TOKEN"},"exit_code":0}"#, match: "cmd"))
54 stub.enqueue(.init(status: 200, json: secretsJSON, match: "argv=list"))
55 let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay")
56
57 #expect(await model.set(name: " DEPLOY_TOKEN ", value: "s3cret"))
58
59 let sent = try body(try #require(stub.seen.first))
60 #expect(sent["argv"] as? [String] == ["repo", "secret", "set", "krz/gitbay", "DEPLOY_TOKEN"])
61 #expect(sent["stdin"] as? String == "s3cret")
62 #expect(model.state.value == ["DEPLOY_TOKEN", "NPM_TOKEN"])
63 }
64
65 @Test func aRefusedNameSaysWhy() async throws {
66 let (client, stub) = try makeClient()
67 stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"secret names are env-var shaped: uppercase letters, digits, _","exit_code":2}"#))
68 let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay")
69
70 #expect(await model.set(name: "lower", value: "x") == false)
71 #expect(model.actionError == "secret names are env-var shaped: uppercase letters, digits, _")
72 }
73
74 @Test func aFailedWriteStillRefreshesTheList() async throws {
75 let (client, stub) = try makeClient()
76 stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no secret NPM_TOKEN on krz/gitbay","exit_code":3}"#, match: "cmd"))
77 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":["DEPLOY_TOKEN"],"exit_code":0}"#, match: "argv=list"))
78 let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay")
79
80 await model.remove("NPM_TOKEN")
81
82 #expect(model.actionError == "no secret NPM_TOKEN on krz/gitbay")
83 #expect(model.state.value == ["DEPLOY_TOKEN"])
84 }
85
86 @Test func aMultiLineValueKeepsItsNewlines() async throws {
87 let (client, stub) = try makeClient()
88 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
89 stub.enqueue(.init(status: 200, json: secretsJSON, match: "argv=list"))
90 let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay")
91 let key = "-----BEGIN KEY-----\nabc\n-----END KEY-----\n"
92
93 #expect(await model.set(name: "BOT_SSH_KEY", value: key))
94
95 #expect(try body(try #require(stub.seen.first))["stdin"] as? String == key)
96 }
97
98 @Test func removeNamesTheSecret() async throws {
99 let (client, stub) = try makeClient()
100 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
101 stub.enqueue(.init(status: 200, json: secretsJSON, match: "argv=list"))
102 let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay")
103
104 await model.remove("NPM_TOKEN")
105
106 #expect(try body(try #require(stub.seen.first))["argv"] as? [String]
107 == ["repo", "secret", "remove", "krz/gitbay", "NPM_TOKEN"])
108 }
109}
110
111@MainActor
112struct DeployKeysViewModelTests {
113
114 @Test func listsKeysWithModeAndDates() async throws {
115 let (client, stub) = try makeClient()
116 stub.enqueue(.init(status: 200, json: deployKeysJSON))
117 let model = DeployKeysViewModel(client: client, repoPath: "krz/gitbay")
118 await model.load()
119
120 let keys = try #require(model.state.value)
121 #expect(keys.map(\.fingerprint) == ["SHA256:abc", "SHA256:def"])
122 #expect(keys[0].canPush && !keys[1].canPush)
123 #expect(keys[0].lastUsedAt != nil && keys[0].expiresAt == nil)
124 #expect(keys[1].lastUsedAt == nil && keys[1].expiresAt != nil)
125 #expect(stub.seen.first?.url.query() == "argv=repo&argv=deploy-key&argv=list&argv=krz/gitbay")
126 }
127
128 @Test func addSendsTheKeyOnStdinWithModeAndLifetime() async throws {
129 let (client, stub) = try makeClient()
130 for _ in 0..<2 {
131 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"fingerprint":"SHA256:x","mode":"ro"},"exit_code":0}"#, match: "cmd"))
132 stub.enqueue(.init(status: 200, json: deployKeysJSON, match: "argv=list"))
133 }
134 let model = DeployKeysViewModel(client: client, repoPath: "krz/gitbay")
135
136 #expect(await model.add(publicKey: "ssh-ed25519 AAAA ci\n", canPush: false, ttl: nil))
137 #expect(await model.add(publicKey: "ssh-ed25519 AAAA ci", canPush: true, ttl: "30d"))
138
139 let writes = try stub.seen.filter { $0.url.path().hasSuffix("/cmd") }.map(body)
140 #expect(writes.map { $0["argv"] as? [String] } == [
141 ["repo", "deploy-key", "add", "krz/gitbay"],
142 ["repo", "deploy-key", "add", "krz/gitbay", "--rw", "--ttl", "30d"],
143 ])
144 #expect(writes.allSatisfy { $0["stdin"] as? String == "ssh-ed25519 AAAA ci" })
145 }
146
147 @Test func removeNamesTheFingerprint() async throws {
148 let (client, stub) = try makeClient()
149 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
150 stub.enqueue(.init(status: 200, json: deployKeysJSON, match: "argv=list"))
151 stub.enqueue(.init(status: 200, json: deployKeysJSON, match: "argv=list"))
152 let model = DeployKeysViewModel(client: client, repoPath: "krz/gitbay")
153 await model.load()
154
155 await model.remove(try #require(model.state.value?.first))
156
157 let writes = try stub.seen.filter { $0.url.path().hasSuffix("/cmd") }.map(body)
158 #expect(writes.first?["argv"] as? [String] == ["repo", "deploy-key", "remove", "krz/gitbay", "SHA256:abc"])
159 }
160}