@ebowwa/terminal 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/manager.ts ADDED
@@ -0,0 +1,319 @@
1
+ /**
2
+ * SSH Key Manager - Single Source of Truth
3
+ *
4
+ * Consolidates SSH key creation, upload, and management for Hetzner.
5
+ * Replaces duplicate ensureSSHKey() functions in api.ts and crud.ts.
6
+ *
7
+ * FEATURES:
8
+ * - Create or reuse local SSH key pairs
9
+ * - Upload public key to Hetzner if not exists
10
+ * - Handle fingerprint format conversion (SHA256 <-> MD5)
11
+ * - Provide consistent API for all SSH key operations
12
+ * - OS-specific user data directory for portable key storage
13
+ *
14
+ * ENVIRONMENT VARIABLES:
15
+ * - HETZNER_SSH_KEYS_DIR: Override default SSH keys directory
16
+ */
17
+
18
+ import { exec } from "child_process";
19
+ import { promisify } from "util";
20
+ import { existsSync, mkdirSync } from "fs";
21
+ import { join } from "path";
22
+ import type { HetznerClient } from "../lib/hetzner/client";
23
+ import type { HetznerSSHKey } from "../lib/hetzner/types";
24
+ import { getLocalKeyFingerprint } from "./fingerprint.js";
25
+
26
+ // TypeScript declaration for environment variable
27
+ declare module "bun" {
28
+ interface Env {
29
+ HETZNER_SSH_KEYS_DIR?: string;
30
+ }
31
+ }
32
+
33
+ const execAsync = promisify(exec);
34
+
35
+ /**
36
+ * Get OS-specific user data directory for SSH keys
37
+ * - macOS: ~/Library/Application Support/com.hetzner.codespaces/ssh-keys/
38
+ * - Linux: ~/.config/com.hetzner.codespaces/ssh-keys/
39
+ * - Windows: %APPDATA%\com.hetzner.codespaces\ssh-keys\
40
+ *
41
+ * Can be overridden via .env file:
42
+ * HETZNER_SSH_KEYS_DIR=/custom/path
43
+ */
44
+ export function getDefaultKeysDir(): string {
45
+ // Allow override via environment variable (Bun reads .env automatically)
46
+ const envDir = Bun.env.HETZNER_SSH_KEYS_DIR || process.env.HETZNER_SSH_KEYS_DIR;
47
+ if (envDir) {
48
+ return envDir;
49
+ }
50
+
51
+ const home = process.env.HOME || process.env.USERPROFILE || "~";
52
+ const appName = "com.hetzner.codespaces";
53
+
54
+ if (process.platform === "darwin") {
55
+ return join(home, "Library", "Application Support", appName, "ssh-keys");
56
+ } else if (process.platform === "linux") {
57
+ return join(home, ".config", appName, "ssh-keys");
58
+ } else if (process.platform === "win32") {
59
+ const appData = process.env.APPDATA || join(home, "AppData", "Roaming");
60
+ return join(appData, appName, "ssh-keys");
61
+ }
62
+
63
+ // Universal fallback
64
+ return join(home, `.${appName}`, "ssh-keys");
65
+ }
66
+
67
+ /**
68
+ * SSH Key information returned by the manager
69
+ */
70
+ export interface SSHKeyInfo {
71
+ keyId: number;
72
+ keyPath: string;
73
+ fingerprint: string;
74
+ name: string;
75
+ }
76
+
77
+ /**
78
+ * Configuration for SSH key manager
79
+ */
80
+ export interface SSHKeyManagerConfig {
81
+ keyName?: string;
82
+ keysDir?: string; // Optional: defaults to OS-specific user data directory
83
+ }
84
+
85
+ /**
86
+ * Default SSH key configuration
87
+ */
88
+ const DEFAULT_KEY_NAME = "hetzner-codespaces-default";
89
+
90
+ /**
91
+ * Fingerprint format types
92
+ */
93
+ export type FingerprintFormat = "md5" | "sha256";
94
+
95
+ /**
96
+ * Convert SSH key fingerprint between formats
97
+ * Hetzner uses MD5 with colons, modern tools use SHA256
98
+ */
99
+ export async function convertFingerprintFormat(
100
+ publicKeyPath: string,
101
+ format: FingerprintFormat
102
+ ): Promise<string> {
103
+ const flag = format === "md5" ? "-E md5" : "";
104
+ const { stdout } = await execAsync(`ssh-keygen -l -f "${publicKeyPath}" ${flag}`);
105
+
106
+ // Extract fingerprint from output
107
+ // MD5 format: "256 MD5:13:ac:a1:ea:4e:ce:9e:8c:32:1a:9e:23:d8:1c:e4:93 comment (ED25519)"
108
+ // SHA256 format: "256 SHA256:+JgBxEtEo1NSfUtA+Lcy7hZrh7nb6j/oo6hmP7x8Hjw comment (ED25519)"
109
+ const parts = stdout.trim().split(/\s+/);
110
+ let fingerprint = parts[1];
111
+
112
+ // Remove "MD5:" prefix if present (Hetzner API returns it without prefix)
113
+ if (fingerprint.startsWith("MD5:")) {
114
+ fingerprint = fingerprint.replace("MD5:", "");
115
+ }
116
+
117
+ return fingerprint;
118
+ }
119
+
120
+ /**
121
+ * Get SSH key fingerprint in the format expected by Hetzner API (MD5 with colons)
122
+ */
123
+ async function getHetznerFingerprint(publicKeyPath: string): Promise<string> {
124
+ return convertFingerprintFormat(publicKeyPath, "md5");
125
+ }
126
+
127
+ /**
128
+ * Get SSH key fingerprint in modern SHA256 format (for local use)
129
+ */
130
+ async function getLocalFingerprint(publicKeyPath: string): Promise<string> {
131
+ return convertFingerprintFormat(publicKeyPath, "sha256");
132
+ }
133
+
134
+ /**
135
+ * Create a new SSH key pair
136
+ */
137
+ async function createKeyPair(keyPath: string, keyName: string): Promise<void> {
138
+ console.log(`[SSH] Generating new SSH key pair: ${keyPath}`);
139
+ // Quote the path to handle spaces in directory names (e.g., "Application Support")
140
+ await execAsync(
141
+ `ssh-keygen -t ed25519 -f "${keyPath}" -N "" -C "${keyName}"`
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Find SSH key in Hetzner by fingerprint
147
+ */
148
+ async function findKeyByFingerprint(
149
+ hetznerClient: HetznerClient,
150
+ fingerprint: string
151
+ ): Promise<HetznerSSHKey | null> {
152
+ const existingKeys = await hetznerClient.ssh_keys.list();
153
+ return existingKeys.find((k) => k.fingerprint === fingerprint) || null;
154
+ }
155
+
156
+ /**
157
+ * Upload new SSH key to Hetzner
158
+ */
159
+ async function uploadKeyToHetzner(
160
+ hetznerClient: HetznerClient,
161
+ keyName: string,
162
+ publicKey: string
163
+ ): Promise<HetznerSSHKey> {
164
+ console.log(`[SSH] Uploading SSH key to Hetzner: ${keyName}`);
165
+ return await hetznerClient.ssh_keys.create({
166
+ name: keyName,
167
+ public_key: publicKey.trim(),
168
+ });
169
+ }
170
+
171
+ /**
172
+ * SSH Key Manager - Main class for managing SSH keys
173
+ */
174
+ export class SSHKeyManager {
175
+ private keyName: string;
176
+ private keysDir: string;
177
+
178
+ constructor(config: SSHKeyManagerConfig = {}) {
179
+ this.keyName = config.keyName || DEFAULT_KEY_NAME;
180
+ // Use provided keysDir or fall back to OS-specific default
181
+ this.keysDir = config.keysDir || getDefaultKeysDir();
182
+ }
183
+
184
+ /**
185
+ * Get the absolute path to the SSH keys directory
186
+ */
187
+ private getKeysDirPath(): string {
188
+ return this.keysDir;
189
+ }
190
+
191
+ /**
192
+ * Get the full path to the SSH key files (absolute path)
193
+ */
194
+ private getKeyPath(): string {
195
+ return join(this.keysDir, this.keyName);
196
+ }
197
+
198
+ /**
199
+ * Ensure SSH key exists locally and on Hetzner
200
+ * This is the main entry point - replaces duplicate ensureSSHKey() functions
201
+ *
202
+ * @param hetznerClient - Hetzner API client
203
+ * @returns SSH key information for server creation
204
+ */
205
+ async ensureSSHKey(hetznerClient: HetznerClient): Promise<SSHKeyInfo> {
206
+ // Ensure .ssh-keys directory exists (use absolute path)
207
+ const keysDirPath = this.getKeysDirPath();
208
+ mkdirSync(keysDirPath, { recursive: true });
209
+
210
+ const keyPath = this.getKeyPath();
211
+ const publicKeyPath = `${keyPath}.pub`;
212
+
213
+ // Check if key file exists locally
214
+ let publicKey: string;
215
+ let fingerprint: string;
216
+
217
+ if (existsSync(publicKeyPath)) {
218
+ // Read existing public key
219
+ publicKey = await Bun.file(publicKeyPath).text();
220
+ // Get fingerprint in Hetzner format (MD5 with colons)
221
+ fingerprint = await getHetznerFingerprint(publicKeyPath);
222
+ } else {
223
+ // Generate new key pair
224
+ await createKeyPair(keyPath, this.keyName);
225
+ publicKey = await Bun.file(publicKeyPath).text();
226
+ fingerprint = await getHetznerFingerprint(publicKeyPath);
227
+ }
228
+
229
+ // Check if key exists on Hetzner by fingerprint
230
+ const existingKey = await findKeyByFingerprint(hetznerClient, fingerprint);
231
+
232
+ if (existingKey) {
233
+ console.log(
234
+ `[SSH] Using existing Hetzner SSH key: ${existingKey.name} (ID: ${existingKey.id})`,
235
+ );
236
+ return {
237
+ keyId: existingKey.id,
238
+ keyPath,
239
+ fingerprint,
240
+ name: existingKey.name,
241
+ };
242
+ }
243
+
244
+ // Upload new key to Hetzner
245
+ try {
246
+ const newKey = await uploadKeyToHetzner(hetznerClient, this.keyName, publicKey);
247
+ console.log(`[SSH] SSH key uploaded: ${newKey.name} (ID: ${newKey.id})`);
248
+ return {
249
+ keyId: newKey.id,
250
+ keyPath,
251
+ fingerprint,
252
+ name: newKey.name,
253
+ };
254
+ } catch (error: any) {
255
+ // Handle "already exists" error - try to find by name
256
+ if (
257
+ error?.message?.includes("not unique") ||
258
+ error?.message?.includes("already exists")
259
+ ) {
260
+ console.log(
261
+ `[SSH] Key already exists in Hetzner, finding by name...`,
262
+ );
263
+ const keyByName = await hetznerClient.ssh_keys.findByName(this.keyName);
264
+
265
+ if (keyByName) {
266
+ console.log(
267
+ `[SSH] Found existing key by name: ${keyByName.name} (ID: ${keyByName.id})`,
268
+ );
269
+ return {
270
+ keyId: keyByName.id,
271
+ keyPath,
272
+ fingerprint: keyByName.fingerprint,
273
+ name: keyByName.name,
274
+ };
275
+ }
276
+ }
277
+ throw error;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Get local key fingerprint in SHA256 format
283
+ */
284
+ async getLocalFingerprint(): Promise<string | null> {
285
+ const keyPath = this.getKeyPath();
286
+ return getLocalKeyFingerprint(keyPath);
287
+ }
288
+
289
+ /**
290
+ * Get local key fingerprint in MD5 format (for Hetzner comparison)
291
+ */
292
+ async getHetznerFingerprint(): Promise<string> {
293
+ const publicKeyPath = `${this.getKeyPath()}.pub`;
294
+ return getHetznerFingerprint(publicKeyPath);
295
+ }
296
+
297
+ /**
298
+ * Get the key path
299
+ */
300
+ getKeyPathValue(): string {
301
+ return this.getKeyPath();
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Convenience function - creates default manager and ensures SSH key
307
+ * This is a drop-in replacement for the old ensureSSHKey() functions
308
+ *
309
+ * @param hetznerClient - Hetzner API client
310
+ * @param config - Optional configuration
311
+ * @returns SSH key information
312
+ */
313
+ export async function ensureSSHKey(
314
+ hetznerClient: HetznerClient,
315
+ config?: SSHKeyManagerConfig
316
+ ): Promise<SSHKeyInfo> {
317
+ const manager = new SSHKeyManager(config);
318
+ return manager.ensureSSHKey(hetznerClient);
319
+ }