Commit f85e26bb97

f85e26bb97194203761fa4b7d0f317adcd8c9d92

parent: 20fdd56fcc

Unsigned

cmc <hello@cleberg.net> · 2026-08-07 21:29 UTC

Deploy keys: manage git.sr.ht repository deploy keys (v3.10.0)

Wire git.sr.ht createDeployKey / deleteDeployKey and Repository.deployKeys
into an owner-only Deploy Keys sheet in the repository actions menu, next to
ACLs: list keys (fingerprint, comment, access), add an SSH public key with
RO/RW access, and delete behind a confirmation.

Verified live against the ~hutch account. The createDeployKey response
returns an empty access value, so create selects only rid and the list is
reloaded rather than decoding the partial key.

Bumps to 3.10.0 (build 95); roadmap marks deploy keys shipped.

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +12 −12
@@ -597,7 +597,7 @@
597597 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
598598 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
599599 CODE_SIGN_STYLE = Automatic;
600 CURRENT_PROJECT_VERSION = 94;
600 CURRENT_PROJECT_VERSION = 95;
601601 DEVELOPMENT_TEAM = ZCNAX3VL9D;
602602 ENABLE_PREVIEWS = YES;
603603 GENERATE_INFOPLIST_FILE = YES;
@@ -614,7 +614,7 @@
614614 "$(inherited)",
615615 "@executable_path/Frameworks",
616616 );
617 MARKETING_VERSION = 3.9.0;
617 MARKETING_VERSION = 3.10.0;
618618 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
619619 PRODUCT_NAME = "$(TARGET_NAME)";
620620 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +634,7 @@
634634 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
635635 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
636636 CODE_SIGN_STYLE = Automatic;
637 CURRENT_PROJECT_VERSION = 94;
637 CURRENT_PROJECT_VERSION = 95;
638638 DEVELOPMENT_TEAM = ZCNAX3VL9D;
639639 ENABLE_PREVIEWS = YES;
640640 GENERATE_INFOPLIST_FILE = YES;
@@ -651,7 +651,7 @@
651651 "$(inherited)",
652652 "@executable_path/Frameworks",
653653 );
654 MARKETING_VERSION = 3.9.0;
654 MARKETING_VERSION = 3.10.0;
655655 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
656656 PRODUCT_NAME = "$(TARGET_NAME)";
657657 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -714,7 +714,7 @@
714714 APPLICATION_EXTENSION_API_ONLY = YES;
715715 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
716716 CODE_SIGN_STYLE = Automatic;
717 CURRENT_PROJECT_VERSION = 94;
717 CURRENT_PROJECT_VERSION = 95;
718718 DEVELOPMENT_TEAM = ZCNAX3VL9D;
719719 GENERATE_INFOPLIST_FILE = NO;
720720 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -724,7 +724,7 @@
724724 "@executable_path/Frameworks",
725725 "@executable_path/../../Frameworks",
726726 );
727 MARKETING_VERSION = 3.9.0;
727 MARKETING_VERSION = 3.10.0;
728728 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
729729 PRODUCT_NAME = "$(TARGET_NAME)";
730730 SKIP_INSTALL = YES;
@@ -743,7 +743,7 @@
743743 APPLICATION_EXTENSION_API_ONLY = YES;
744744 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
745745 CODE_SIGN_STYLE = Automatic;
746 CURRENT_PROJECT_VERSION = 94;
746 CURRENT_PROJECT_VERSION = 95;
747747 DEVELOPMENT_TEAM = ZCNAX3VL9D;
748748 GENERATE_INFOPLIST_FILE = NO;
749749 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -753,7 +753,7 @@
753753 "@executable_path/Frameworks",
754754 "@executable_path/../../Frameworks",
755755 );
756 MARKETING_VERSION = 3.9.0;
756 MARKETING_VERSION = 3.10.0;
757757 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
758758 PRODUCT_NAME = "$(TARGET_NAME)";
759759 SKIP_INSTALL = YES;
@@ -772,7 +772,7 @@
772772 APPLICATION_EXTENSION_API_ONLY = YES;
773773 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
774774 CODE_SIGN_STYLE = Automatic;
775 CURRENT_PROJECT_VERSION = 94;
775 CURRENT_PROJECT_VERSION = 95;
776776 DEVELOPMENT_TEAM = ZCNAX3VL9D;
777777 GENERATE_INFOPLIST_FILE = NO;
778778 INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -782,7 +782,7 @@
782782 "@executable_path/Frameworks",
783783 "@executable_path/../../Frameworks",
784784 );
785 MARKETING_VERSION = 3.9.0;
785 MARKETING_VERSION = 3.10.0;
786786 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
787787 PRODUCT_NAME = "$(TARGET_NAME)";
788788 SKIP_INSTALL = YES;
@@ -801,7 +801,7 @@
801801 APPLICATION_EXTENSION_API_ONLY = YES;
802802 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
803803 CODE_SIGN_STYLE = Automatic;
804 CURRENT_PROJECT_VERSION = 94;
804 CURRENT_PROJECT_VERSION = 95;
805805 DEVELOPMENT_TEAM = ZCNAX3VL9D;
806806 GENERATE_INFOPLIST_FILE = NO;
807807 INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -811,7 +811,7 @@
811811 "@executable_path/Frameworks",
812812 "@executable_path/../../Frameworks",
813813 );
814 MARKETING_VERSION = 3.9.0;
814 MARKETING_VERSION = 3.10.0;
815815 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
816816 PRODUCT_NAME = "$(TARGET_NAME)";
817817 SKIP_INSTALL = YES;
Hutch/Networking/RepositoryDeployKeyService.swift added +100
@@ -0,0 +1,100 @@
1import Foundation
2
3/// A per-repository deploy key (git.sr.ht `SSHKey` under `Repository.deployKeys`).
4struct RepositoryDeployKey: Decodable, Sendable, Identifiable, Hashable {
5 let rid: String
6 let keyType: String
7 let fingerprintSHA256: String
8 let comment: String?
9 let access: AccessMode
10
11 var id: String { rid }
12}
13
14private struct DeployKeysQueryResponse: Decodable, Sendable {
15 let repository: DeployKeysRepository?
16}
17
18private struct DeployKeysRepository: Decodable, Sendable {
19 let deployKeys: DeployKeysPage
20}
21
22private struct DeployKeysPage: Decodable, Sendable {
23 let results: [RepositoryDeployKey]
24}
25
26/// `createDeployKey`'s response returns an empty `access` (the stored value is
27/// correct — the list query reports it), so we select only `rid` here and let
28/// callers reload rather than decode the partial key.
29private struct CreateDeployKeyResponse: Decodable, Sendable {}
30
31/// Delete returns the removed key; only success matters here.
32private struct DeleteDeployKeyResponse: Decodable, Sendable {}
33
34/// Deploy keys are a git.sr.ht capability (`createDeployKey` / `deleteDeployKey`),
35/// owner-only, alongside repository ACLs.
36struct RepositoryDeployKeyService: Sendable {
37 private let client: SRHTClient
38
39 init(client: SRHTClient) {
40 self.client = client
41 }
42
43 func fetchDeployKeys(repositoryRid: String) async throws -> [RepositoryDeployKey] {
44 let response = try await client.execute(
45 service: .git,
46 query: Self.deployKeysQuery,
47 variables: ["rid": repositoryRid],
48 responseType: DeployKeysQueryResponse.self
49 )
50 return response.repository?.deployKeys.results ?? []
51 }
52
53 func createDeployKey(repositoryRid: String, mode: AccessMode, key: String) async throws {
54 _ = try await client.execute(
55 service: .git,
56 query: Self.createDeployKeyMutation,
57 variables: ["repo": repositoryRid, "mode": mode.rawValue, "key": key],
58 responseType: CreateDeployKeyResponse.self
59 )
60 }
61
62 func deleteDeployKey(rid: String) async throws {
63 _ = try await client.execute(
64 service: .git,
65 query: Self.deleteDeployKeyMutation,
66 variables: ["rid": rid],
67 responseType: DeleteDeployKeyResponse.self
68 )
69 }
70}
71
72private extension RepositoryDeployKeyService {
73 static let deployKeysQuery = """
74 query repositoryDeployKeys($rid: ID!) {
75 repository(rid: $rid) {
76 deployKeys {
77 results {
78 rid
79 keyType
80 fingerprintSHA256
81 comment
82 access
83 }
84 }
85 }
86 }
87 """
88
89 static let createDeployKeyMutation = """
90 mutation createDeployKey($repo: ID!, $mode: AccessMode!, $key: String!) {
91 createDeployKey(repo: $repo, mode: $mode, key: $key) { rid }
92 }
93 """
94
95 static let deleteDeployKeyMutation = """
96 mutation deleteDeployKey($rid: ID!) {
97 deleteDeployKey(rid: $rid) { rid }
98 }
99 """
100}
Hutch/Views/Repositories/RepositoryDeployKeysView.swift added +272
@@ -0,0 +1,272 @@
1import SwiftUI
2
3@Observable
4@MainActor
5final class RepositoryDeployKeysViewModel {
6 private(set) var keys: [RepositoryDeployKey] = []
7 private(set) var isLoading = false
8 private(set) var isSaving = false
9 private(set) var deletingRID: String?
10 var loadError: String?
11 var error: String?
12 var saveError: String?
13
14 let repositoryRid: String
15 private let service: RepositoryDeployKeyService
16
17 init(repositoryRid: String, service: RepositoryDeployKeyService) {
18 self.repositoryRid = repositoryRid
19 self.service = service
20 }
21
22 func load() async {
23 guard !isLoading else { return }
24 isLoading = true
25 loadError = nil
26 defer { isLoading = false }
27 do {
28 keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid)
29 } catch {
30 if keys.isEmpty {
31 loadError = error.userFacingMessage
32 } else {
33 self.error = error.userFacingMessage
34 }
35 }
36 }
37
38 func addKey(publicKey: String, mode: AccessMode) async -> Bool {
39 let trimmed = publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
40 guard !trimmed.isEmpty, !isSaving else { return false }
41 isSaving = true
42 saveError = nil
43 defer { isSaving = false }
44 do {
45 try await service.createDeployKey(repositoryRid: repositoryRid, mode: mode, key: trimmed)
46 // The create response omits the key's fields, so reload the list.
47 keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid)
48 return true
49 } catch {
50 saveError = error.userFacingMessage
51 return false
52 }
53 }
54
55 func deleteKey(_ key: RepositoryDeployKey) async {
56 guard deletingRID == nil else { return }
57 deletingRID = key.rid
58 error = nil
59 defer { deletingRID = nil }
60 do {
61 try await service.deleteDeployKey(rid: key.rid)
62 keys.removeAll { $0.rid == key.rid }
63 } catch {
64 self.error = error.userFacingMessage
65 }
66 }
67}
68
69struct RepositoryDeployKeysView: View {
70 let repository: RepositorySummary
71 let client: SRHTClient
72 var showsDoneButton = false
73
74 @Environment(\.dismiss) private var dismiss
75 @State private var viewModel: RepositoryDeployKeysViewModel?
76 @State private var showAddSheet = false
77 @State private var pendingDeletion: RepositoryDeployKey?
78
79 var body: some View {
80 Group {
81 if let viewModel {
82 content(viewModel)
83 } else {
84 SRHTLoadingStateView(message: "Loading deploy keys…")
85 }
86 }
87 .navigationTitle("Deploy Keys")
88 .navigationBarTitleDisplayMode(.inline)
89 .toolbar {
90 if showsDoneButton {
91 ToolbarItem(placement: .cancellationAction) {
92 Button("Done") { dismiss() }
93 }
94 }
95 if viewModel != nil {
96 ToolbarItem(placement: .topBarTrailing) {
97 Button {
98 showAddSheet = true
99 } label: {
100 Image(systemName: "plus")
101 }
102 .accessibilityLabel("Add deploy key")
103 }
104 }
105 }
106 .task {
107 if viewModel == nil {
108 let vm = RepositoryDeployKeysViewModel(
109 repositoryRid: repository.rid,
110 service: RepositoryDeployKeyService(client: client)
111 )
112 viewModel = vm
113 await vm.load()
114 }
115 }
116 }
117
118 @ViewBuilder
119 private func content(_ viewModel: RepositoryDeployKeysViewModel) -> some View {
120 Group {
121 if viewModel.isLoading, viewModel.keys.isEmpty, viewModel.loadError == nil {
122 SRHTLoadingStateView(message: "Loading deploy keys…")
123 } else if let loadError = viewModel.loadError, viewModel.keys.isEmpty {
124 SRHTErrorStateView(
125 title: "Couldn't Load Deploy Keys",
126 message: loadError,
127 retryAction: { await viewModel.load() }
128 )
129 } else {
130 List {
131 if viewModel.keys.isEmpty {
132 Section {
133 ContentUnavailableView(
134 "No Deploy Keys",
135 systemImage: "key",
136 description: Text("Add an SSH public key to grant this repository read or read/write access for automation.")
137 )
138 .themedRow()
139 }
140 } else {
141 Section {
142 ForEach(viewModel.keys) { key in
143 DeployKeyRow(key: key, isDeleting: viewModel.deletingRID == key.rid)
144 .themedRow()
145 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
146 Button(role: .destructive) {
147 pendingDeletion = key
148 } label: {
149 Label("Delete", systemImage: "trash")
150 }
151 }
152 }
153 } footer: {
154 Text("Deploy keys are SSH keys scoped to this repository only.")
155 }
156 }
157 }
158 .themedList()
159 .refreshable { await viewModel.load() }
160 }
161 }
162 .srhtErrorBanner(error: Binding(get: { viewModel.error }, set: { viewModel.error = $0 }))
163 .confirmationDialog(
164 "Delete this deploy key?",
165 isPresented: Binding(get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } }),
166 titleVisibility: .visible
167 ) {
168 Button("Cancel", role: .cancel) { pendingDeletion = nil }
169 Button("Delete", role: .destructive) {
170 if let key = pendingDeletion {
171 pendingDeletion = nil
172 Task { await viewModel.deleteKey(key) }
173 }
174 }
175 } message: {
176 Text("This revokes the key's access to \(repository.name). This cannot be undone.")
177 }
178 .sheet(isPresented: $showAddSheet) {
179 AddDeployKeyView(viewModel: viewModel)
180 }
181 }
182}
183
184private struct DeployKeyRow: View {
185 let key: RepositoryDeployKey
186 let isDeleting: Bool
187
188 var body: some View {
189 HStack(spacing: 12) {
190 VStack(alignment: .leading, spacing: 3) {
191 Text(key.comment?.isEmpty == false ? key.comment! : key.keyType)
192 .font(.body)
193 .lineLimit(1)
194 Text(key.fingerprintSHA256)
195 .font(.caption.monospaced())
196 .foregroundStyle(.secondary)
197 .lineLimit(1)
198 .truncationMode(.middle)
199 }
200 Spacer()
201 if isDeleting {
202 ProgressView().controlSize(.small)
203 } else {
204 Text(key.access.displayName)
205 .font(.caption.weight(.medium))
206 .foregroundStyle(.secondary)
207 }
208 }
209 .padding(.vertical, 2)
210 }
211}
212
213private struct AddDeployKeyView: View {
214 let viewModel: RepositoryDeployKeysViewModel
215
216 @Environment(\.dismiss) private var dismiss
217 @State private var publicKey = ""
218 @State private var mode: AccessMode = .ro
219
220 var body: some View {
221 NavigationStack {
222 Form {
223 Section("SSH Public Key") {
224 TextField("ssh-ed25519 AAAA… comment", text: $publicKey, axis: .vertical)
225 .lineLimit(3...8)
226 .textInputAutocapitalization(.never)
227 .autocorrectionDisabled()
228 .font(.body.monospaced())
229 .themedRow()
230 }
231 Section {
232 Picker("Access", selection: $mode) {
233 Text("Read Only").tag(AccessMode.ro)
234 Text("Read/Write").tag(AccessMode.rw)
235 }
236 .themedRow()
237 } footer: {
238 Text("Read/Write lets the key push to this repository.")
239 }
240 if let saveError = viewModel.saveError, !saveError.isEmpty {
241 Section {
242 Text(saveError).foregroundStyle(.red).themedRow()
243 }
244 }
245 }
246 .themedList()
247 .navigationTitle("Add Deploy Key")
248 .navigationBarTitleDisplayMode(.inline)
249 .toolbar {
250 ToolbarItem(placement: .cancellationAction) {
251 Button("Cancel") { dismiss() }
252 }
253 ToolbarItem(placement: .confirmationAction) {
254 Button {
255 Task {
256 if await viewModel.addKey(publicKey: publicKey, mode: mode) {
257 dismiss()
258 }
259 }
260 } label: {
261 if viewModel.isSaving {
262 ProgressView().controlSize(.small)
263 } else {
264 Text("Add")
265 }
266 }
267 .disabled(publicKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSaving)
268 }
269 }
270 }
271 }
272}
Hutch/Views/Repositories/RepositoryDetailView.swift +16
@@ -12,6 +12,7 @@ struct RepositoryDetailView: View {
1212 @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary
1313 @State private var showSettings = false
1414 @State private var showACLs = false
15 @State private var showDeployKeys = false
1516 @State private var currentRepository: RepositorySummary
1617 @State private var pinChangeCount = 0
1718
@@ -82,6 +83,15 @@ struct RepositoryDetailView: View {
8283 )
8384 }
8485 }
86 .sheet(isPresented: $showDeployKeys) {
87 NavigationStack {
88 RepositoryDeployKeysView(
89 repository: currentRepository,
90 client: appState.client,
91 showsDoneButton: true
92 )
93 }
94 }
8595 .task {
8696 if viewModel == nil {
8797 viewModel = RepositoryDetailViewModel(
@@ -200,6 +210,12 @@ struct RepositoryDetailView: View {
200210 Label("Manage ACLs", systemImage: "person.2")
201211 }
202212
213 Button {
214 showDeployKeys = true
215 } label: {
216 Label("Deploy Keys", systemImage: "key")
217 }
218
203219 Button {
204220 showSettings = true
205221 } label: {
HutchTests/RepositoryDeployKeyTests.swift added +27
@@ -0,0 +1,27 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct RepositoryDeployKeyTests {
6 @Test
7 func decodesDeployKey() throws {
8 let json = """
9 { "rid": "k1", "keyType": "ssh-ed25519", "fingerprintSHA256": "SHA256:abc", "comment": "laptop", "access": "RW" }
10 """
11 let key = try JSONDecoder().decode(RepositoryDeployKey.self, from: Data(json.utf8))
12 #expect(key.rid == "k1")
13 #expect(key.id == "k1")
14 #expect(key.access == .rw)
15 #expect(key.comment == "laptop")
16 }
17
18 @Test
19 func decodesDeployKeyWithNullComment() throws {
20 let json = """
21 { "rid": "k2", "keyType": "ssh-rsa", "fingerprintSHA256": "SHA256:def", "comment": null, "access": "RO" }
22 """
23 let key = try JSONDecoder().decode(RepositoryDeployKey.self, from: Data(json.utf8))
24 #expect(key.comment == nil)
25 #expect(key.access == .ro)
26 }
27}
ROADMAP.txt +11 −10
@@ -144,7 +144,7 @@ so "breaking change" does not apply. These buckets track *user-visible scale*.
144144| v3.8.1 | SonarCloud triage; housekeeping | No behaviour change at all |
145145| v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface |
146146| v3.9.0 | ~~hub.sr.ht project writes + discovery (#12–#15); multi-language highlighting (#16); App Intents expansion (#17); man-page catalog sync (#7); checklist / recent-activity / pull-to-refresh fixes (#18, #11, #9)~~ | Shipped — the cut this session |
147| v3.10.0 | "What's cooking" ingest; doc truth-up; deploy keys; revisit `mailingListSubscribe` | Ships one feature, corrects the map |
147| v3.10.0 | ~~git.sr.ht deploy keys~~ (shipped); "What's cooking" ingest; doc truth-up; revisit `mailingListSubscribe` | Ships one feature, corrects the map |
148148| v3.11.0 | Accessibility | Independent, device-verified |
149149| v4.0.0 | Localization *with* translations | The only true re-presentation |
150150| — | Swift 6 language mode; cache reads | Internal; ride along, no tag |
@@ -276,7 +276,7 @@ exception earns the release its own line: the `forceRefresh` fix changes what
276276pull-to-refresh does, so it needs a manual pass on a device before v3.8.1 ships,
277277not just a green suite.
278278
279### Ingest "What's cooking on SourceHut?" — v3.9.0
279### Ingest "What's cooking on SourceHut?" — v3.10.0
280280
281281sr.ht posts a quarterly update to `~sircmpwn/sr.ht-announce`, mirrored at
282282<https://sourcehut.org/blog/>. Nothing in Hutch tracks it, so the API grows and
@@ -294,19 +294,20 @@ flags two openings:
294294- **hub.sr.ht gained a writable GraphQL API** for managing projects and project
295295 resources. ~~Rechecked and shipped~~: project create/edit, resource
296296 link/unlink, and public discovery landed (#12–#15) — see "hub.sr.ht writes"
297 below. `SCOPE.txt` still needs its "hub has no public API / no discovery"
298 claim corrected. `mailingListSubscribe` was *not* unblocked — that needs a
297 below. `SCOPE.txt`'s "hub has no public API / no discovery" claim has since
298 been corrected. `mailingListSubscribe` was *not* unblocked — that needs a
299299 per-list subscription field lists.sr.ht still lacks.
300- **git.sr.ht deploy keys are complete** (`createDeployKey` / `deleteDeployKey`
301 are in the SDL). Hutch never calls them.
300- ~~git.sr.ht deploy keys are complete~~ — **shipped** (v3.10.0).
301 `createDeployKey` / `deleteDeployKey` (and `Repository.deployKeys`) are wired
302 into the repository actions menu, owner-only, alongside ACLs.
302303
303304Start from Q1 2026 forward — that is roughly when the current `Docs/API` dumps
304305were captured.
305306
306Research does not ship, so v3.9.0 pairs the ingest with **deploy keys** — the one
307self-contained feature it has already surfaced and that the SDL confirms exists.
308That gives the release something a user can see. Everything else the ingest turns
309up gets filed, not built, and hub.sr.ht gets its own bucket below.
307Deploy keys — the one self-contained feature the ingest had already surfaced and
308that the SDL confirmed — shipped in v3.10.0, so this bucket is now the ingest
309itself: research that files what changed rather than building. Everything else it
310turns up gets filed, not built.
310311
311312### hub.sr.ht writes — projects and discovery done
312313