@iterable/cli 0.1.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.
Files changed (140) hide show
  1. package/COMMANDS.md +1574 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +194 -0
  4. package/dist/commands/campaigns.d.ts +3 -0
  5. package/dist/commands/campaigns.d.ts.map +1 -0
  6. package/dist/commands/campaigns.js +106 -0
  7. package/dist/commands/campaigns.js.map +1 -0
  8. package/dist/commands/catalogs.d.ts +3 -0
  9. package/dist/commands/catalogs.d.ts.map +1 -0
  10. package/dist/commands/catalogs.js +99 -0
  11. package/dist/commands/catalogs.js.map +1 -0
  12. package/dist/commands/events.d.ts +3 -0
  13. package/dist/commands/events.d.ts.map +1 -0
  14. package/dist/commands/events.js +33 -0
  15. package/dist/commands/events.js.map +1 -0
  16. package/dist/commands/experiments.d.ts +3 -0
  17. package/dist/commands/experiments.d.ts.map +1 -0
  18. package/dist/commands/experiments.js +33 -0
  19. package/dist/commands/experiments.js.map +1 -0
  20. package/dist/commands/export.d.ts +3 -0
  21. package/dist/commands/export.d.ts.map +1 -0
  22. package/dist/commands/export.js +33 -0
  23. package/dist/commands/export.js.map +1 -0
  24. package/dist/commands/journeys.d.ts +3 -0
  25. package/dist/commands/journeys.d.ts.map +1 -0
  26. package/dist/commands/journeys.js +21 -0
  27. package/dist/commands/journeys.js.map +1 -0
  28. package/dist/commands/lists.d.ts +3 -0
  29. package/dist/commands/lists.d.ts.map +1 -0
  30. package/dist/commands/lists.js +64 -0
  31. package/dist/commands/lists.js.map +1 -0
  32. package/dist/commands/messaging.d.ts +3 -0
  33. package/dist/commands/messaging.d.ts.map +1 -0
  34. package/dist/commands/messaging.js +120 -0
  35. package/dist/commands/messaging.js.map +1 -0
  36. package/dist/commands/registry.d.ts +46 -0
  37. package/dist/commands/registry.d.ts.map +1 -0
  38. package/dist/commands/registry.js +42 -0
  39. package/dist/commands/registry.js.map +1 -0
  40. package/dist/commands/snippets.d.ts +3 -0
  41. package/dist/commands/snippets.d.ts.map +1 -0
  42. package/dist/commands/snippets.js +42 -0
  43. package/dist/commands/snippets.js.map +1 -0
  44. package/dist/commands/subscriptions.d.ts +3 -0
  45. package/dist/commands/subscriptions.d.ts.map +1 -0
  46. package/dist/commands/subscriptions.js +40 -0
  47. package/dist/commands/subscriptions.js.map +1 -0
  48. package/dist/commands/templates.d.ts +3 -0
  49. package/dist/commands/templates.d.ts.map +1 -0
  50. package/dist/commands/templates.js +160 -0
  51. package/dist/commands/templates.js.map +1 -0
  52. package/dist/commands/transforms.d.ts +3 -0
  53. package/dist/commands/transforms.d.ts.map +1 -0
  54. package/dist/commands/transforms.js +24 -0
  55. package/dist/commands/transforms.js.map +1 -0
  56. package/dist/commands/types.d.ts +40 -0
  57. package/dist/commands/types.d.ts.map +1 -0
  58. package/dist/commands/types.js +15 -0
  59. package/dist/commands/types.js.map +1 -0
  60. package/dist/commands/users.d.ts +3 -0
  61. package/dist/commands/users.d.ts.map +1 -0
  62. package/dist/commands/users.js +103 -0
  63. package/dist/commands/users.js.map +1 -0
  64. package/dist/commands/webhooks.d.ts +3 -0
  65. package/dist/commands/webhooks.d.ts.map +1 -0
  66. package/dist/commands/webhooks.js +21 -0
  67. package/dist/commands/webhooks.js.map +1 -0
  68. package/dist/config.d.ts +14 -0
  69. package/dist/config.d.ts.map +1 -0
  70. package/dist/config.js +60 -0
  71. package/dist/config.js.map +1 -0
  72. package/dist/errors.d.ts +11 -0
  73. package/dist/errors.d.ts.map +1 -0
  74. package/dist/errors.js +21 -0
  75. package/dist/errors.js.map +1 -0
  76. package/dist/index.d.ts +3 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +107 -0
  79. package/dist/index.js.map +1 -0
  80. package/dist/key-manager.d.ts +280 -0
  81. package/dist/key-manager.d.ts.map +1 -0
  82. package/dist/key-manager.js +989 -0
  83. package/dist/key-manager.js.map +1 -0
  84. package/dist/keys-cli.d.ts +3 -0
  85. package/dist/keys-cli.d.ts.map +1 -0
  86. package/dist/keys-cli.js +396 -0
  87. package/dist/keys-cli.js.map +1 -0
  88. package/dist/output.d.ts +5 -0
  89. package/dist/output.d.ts.map +1 -0
  90. package/dist/output.js +104 -0
  91. package/dist/output.js.map +1 -0
  92. package/dist/parser.d.ts +26 -0
  93. package/dist/parser.d.ts.map +1 -0
  94. package/dist/parser.js +281 -0
  95. package/dist/parser.js.map +1 -0
  96. package/dist/router.d.ts +20 -0
  97. package/dist/router.d.ts.map +1 -0
  98. package/dist/router.js +137 -0
  99. package/dist/router.js.map +1 -0
  100. package/dist/utils/cli-env.d.ts +10 -0
  101. package/dist/utils/cli-env.d.ts.map +1 -0
  102. package/dist/utils/cli-env.js +21 -0
  103. package/dist/utils/cli-env.js.map +1 -0
  104. package/dist/utils/command-info.d.ts +7 -0
  105. package/dist/utils/command-info.d.ts.map +1 -0
  106. package/dist/utils/command-info.js +36 -0
  107. package/dist/utils/command-info.js.map +1 -0
  108. package/dist/utils/detect-background.d.ts +3 -0
  109. package/dist/utils/detect-background.d.ts.map +1 -0
  110. package/dist/utils/detect-background.js +33 -0
  111. package/dist/utils/detect-background.js.map +1 -0
  112. package/dist/utils/endpoint-prompt.d.ts +5 -0
  113. package/dist/utils/endpoint-prompt.d.ts.map +1 -0
  114. package/dist/utils/endpoint-prompt.js +98 -0
  115. package/dist/utils/endpoint-prompt.js.map +1 -0
  116. package/dist/utils/formatting.d.ts +3 -0
  117. package/dist/utils/formatting.d.ts.map +1 -0
  118. package/dist/utils/formatting.js +5 -0
  119. package/dist/utils/formatting.js.map +1 -0
  120. package/dist/utils/password-prompt.d.ts +3 -0
  121. package/dist/utils/password-prompt.d.ts.map +1 -0
  122. package/dist/utils/password-prompt.js +21 -0
  123. package/dist/utils/password-prompt.js.map +1 -0
  124. package/dist/utils/sanitize.d.ts +13 -0
  125. package/dist/utils/sanitize.d.ts.map +1 -0
  126. package/dist/utils/sanitize.js +23 -0
  127. package/dist/utils/sanitize.js.map +1 -0
  128. package/dist/utils/theme.d.ts +11 -0
  129. package/dist/utils/theme.d.ts.map +1 -0
  130. package/dist/utils/theme.js +14 -0
  131. package/dist/utils/theme.js.map +1 -0
  132. package/dist/utils/ui.d.ts +22 -0
  133. package/dist/utils/ui.d.ts.map +1 -0
  134. package/dist/utils/ui.js +107 -0
  135. package/dist/utils/ui.js.map +1 -0
  136. package/dist/utils/url.d.ts +13 -0
  137. package/dist/utils/url.d.ts.map +1 -0
  138. package/dist/utils/url.js +20 -0
  139. package/dist/utils/url.js.map +1 -0
  140. package/package.json +90 -0
@@ -0,0 +1,989 @@
1
+ /**
2
+ * Secure API Key Management with cross-platform support
3
+ *
4
+ * This module provides secure storage and management of multiple Iterable API keys.
5
+ *
6
+ * Storage Strategy:
7
+ * - macOS: API keys in Keychain, metadata in ~/.iterable/keys.json
8
+ * - Windows: API keys encrypted with DPAPI in ~/.iterable/keys.json
9
+ * - Linux: API keys and metadata in ~/.iterable/keys.json (mode 0o600)
10
+ * - Lock file: ~/.iterable/keys.lock prevents concurrent modifications
11
+ *
12
+ * Security Features:
13
+ * - API key format validation (32-char lowercase hex)
14
+ * - HTTPS-only URL validation (except localhost)
15
+ * - Duplicate key detection (both names and values)
16
+ * - File-based locking for concurrent access protection
17
+ * - Restrictive file permissions where supported (Linux/macOS)
18
+ */
19
+ import { logger } from "@iterable/api";
20
+ import { execFile } from "child_process";
21
+ import { randomUUID } from "crypto";
22
+ import { promises as fs } from "fs";
23
+ import os from "os";
24
+ import path from "path";
25
+ import { promisify } from "util";
26
+ import { COMMAND_NAME } from "./utils/command-info.js";
27
+ import { isHttpsOrLocalhost, isLocalhostHost } from "./utils/url.js";
28
+ // Service name for keychain entries
29
+ // IMPORTANT: This must be constant to avoid data loss. Never use NODE_ENV here!
30
+ // Tests should use dependency injection with mock execSecurity instead.
31
+ const SERVICE_NAME = "iterable-cli";
32
+ const execFileAsync = promisify(execFile);
33
+ /**
34
+ * Safely execute macOS security command with proper argument escaping
35
+ * Uses execFile to prevent shell injection vulnerabilities
36
+ */
37
+ async function execSecurityDefault(args) {
38
+ try {
39
+ const { stdout } = await execFileAsync("security", args);
40
+ return stdout.trim();
41
+ }
42
+ catch (error) {
43
+ const errObj = error;
44
+ const stderr = errObj.stderr?.toString().trim();
45
+ const message = error instanceof Error ? error.message : String(error);
46
+ throw new Error(`Security command failed: ${stderr ?? message}`);
47
+ }
48
+ }
49
+ /**
50
+ * Storage method for API keys
51
+ */
52
+ var StorageMethod;
53
+ (function (StorageMethod) {
54
+ /** macOS Keychain - keys stored and encrypted by OS keychain service */
55
+ StorageMethod["KEYCHAIN"] = "keychain";
56
+ /** Windows DPAPI - keys encrypted by OS (user-scoped keys), stored in JSON file */
57
+ StorageMethod["DPAPI"] = "dpapi";
58
+ /** File storage - plaintext keys in JSON file with restrictive permissions (Linux/other) */
59
+ StorageMethod["FILE"] = "file";
60
+ })(StorageMethod || (StorageMethod = {}));
61
+ export class KeyManager {
62
+ configDir;
63
+ metadataFile;
64
+ lockFile;
65
+ store = null;
66
+ saveLock = null;
67
+ execSecurity;
68
+ storageMethod;
69
+ /**
70
+ * Create a new KeyManager instance
71
+ *
72
+ * @param configDir - Optional custom config directory (defaults to ~/.iterable)
73
+ * @param execSecurity - Optional security command executor (for dependency injection in tests)
74
+ */
75
+ constructor(configDir, execSecurity) {
76
+ this.configDir = configDir || path.join(os.homedir(), ".iterable");
77
+ this.metadataFile = path.join(this.configDir, "keys.json");
78
+ this.lockFile = path.join(this.configDir, "keys.lock");
79
+ this.execSecurity = execSecurity || execSecurityDefault;
80
+ // Determine storage method based on platform
81
+ // Can be overridden via ITERABLE_FORCE_FILE_STORAGE=true
82
+ if (process.env.ITERABLE_FORCE_FILE_STORAGE === "true") {
83
+ // Force file storage mode (for testing or debugging)
84
+ this.storageMethod = StorageMethod.FILE;
85
+ }
86
+ else if (execSecurity || process.platform === "darwin") {
87
+ // Use Keychain on macOS or when execSecurity is provided (for tests)
88
+ this.storageMethod = StorageMethod.KEYCHAIN;
89
+ }
90
+ else if (process.platform === "win32") {
91
+ // Use DPAPI on Windows
92
+ this.storageMethod = StorageMethod.DPAPI;
93
+ }
94
+ else {
95
+ // Use file storage on other platforms
96
+ this.storageMethod = StorageMethod.FILE;
97
+ }
98
+ }
99
+ /**
100
+ * Validate metadata against keychain and clean up orphaned entries (macOS only)
101
+ *
102
+ * Checks if each key in metadata still exists in the keychain.
103
+ * Removes any metadata entries that no longer have corresponding keychain entries.
104
+ * This prevents sync issues when keychain entries are manually deleted.
105
+ *
106
+ * @returns Array of cleaned up key names (if any)
107
+ */
108
+ async validateAndCleanup() {
109
+ // Only validate Keychain on macOS
110
+ if (this.storageMethod !== StorageMethod.KEYCHAIN ||
111
+ !this.store ||
112
+ this.store.keys.length === 0) {
113
+ return [];
114
+ }
115
+ const orphanedKeys = [];
116
+ // Check each key to see if it exists in keychain
117
+ for (const keyMeta of this.store.keys) {
118
+ try {
119
+ await this.execSecurity([
120
+ "find-generic-password",
121
+ "-a",
122
+ keyMeta.id,
123
+ "-s",
124
+ SERVICE_NAME,
125
+ "-w",
126
+ ]);
127
+ // Key exists - no action needed
128
+ }
129
+ catch (error) {
130
+ // Key doesn't exist in keychain - mark for removal
131
+ const message = error instanceof Error ? error.message : String(error);
132
+ if (message.includes("could not be found")) {
133
+ orphanedKeys.push(keyMeta);
134
+ }
135
+ }
136
+ }
137
+ if (orphanedKeys.length > 0) {
138
+ // SAFETY: Don't automatically delete metadata if we can't find keychain entries
139
+ // This could be a false positive due to keychain access issues, permissions, etc.
140
+ const summary = orphanedKeys.map((k) => ({ id: k.id, name: k.name }));
141
+ logger.warn("Keychain mismatch detected; metadata NOT automatically deleted", {
142
+ action: "NOT_DELETED",
143
+ count: orphanedKeys.length,
144
+ });
145
+ // Provide a concise, actionable console message for humans
146
+ console.warn("\n⚠️ Keychain mismatch detected: the following key(s) exist in metadata but not in macOS Keychain.\n");
147
+ for (const { id, name } of summary) {
148
+ console.warn(` • ${name} (ID: ${id})`);
149
+ console.warn(` Delete from metadata: ${COMMAND_NAME} keys delete "${id}"`);
150
+ console.warn(` macOS manual cleanup: security delete-generic-password -a "${id}" -s "${SERVICE_NAME}"`);
151
+ }
152
+ console.warn("\nNext steps:");
153
+ console.warn(` 1) List keys: ${COMMAND_NAME} keys list`);
154
+ console.warn(" 2) Delete any orphaned keys using the ID shown above");
155
+ console.warn(" 3) Re-run your command after cleanup\n");
156
+ // DO NOT automatically delete the metadata!
157
+ // Users should manually verify and clean up with 'keys delete' command
158
+ // This prevents data loss from false positives (permissions, keychain locked, etc.)
159
+ return orphanedKeys.map((k) => k.name);
160
+ }
161
+ return [];
162
+ }
163
+ /**
164
+ * Acquire an exclusive lock for metadata operations
165
+ *
166
+ * Uses atomic file creation (wx flag = O_CREAT | O_EXCL) to prevent concurrent
167
+ * modifications. Retries up to 20 times with 50ms delays (1 second total).
168
+ * Fails safely if lock cannot be acquired rather than forcefully stealing it.
169
+ *
170
+ * Lock contention is rare since writes only happen during explicit user operations
171
+ * (add, delete, activate keys) - no automatic updates.
172
+ *
173
+ * @returns An async unlock function that should be called in a finally block
174
+ * @throws {Error} If lock cannot be acquired after 1 second timeout
175
+ */
176
+ async acquireLock() {
177
+ const maxAttempts = 20; // Max 20 attempts (1 second with 50ms delay)
178
+ const maxLockAgeMs = 5 * 60 * 1000; // 5 minutes
179
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
180
+ try {
181
+ // Try to create lock file exclusively (atomic operation)
182
+ const lockData = JSON.stringify({
183
+ pid: process.pid,
184
+ created: Date.now(),
185
+ });
186
+ await fs.writeFile(this.lockFile, lockData, {
187
+ // Create exclusively and set restrictive permissions on first write
188
+ flag: "wx", // O_CREAT | O_EXCL - atomic, no TOCTOU
189
+ mode: 0o600, // owner read/write only
190
+ });
191
+ // Successfully acquired lock - return unlock function
192
+ return async () => {
193
+ try {
194
+ // Only remove the lock if it still belongs to this process
195
+ const data = await fs
196
+ .readFile(this.lockFile, "utf-8")
197
+ .catch(() => "");
198
+ if (data) {
199
+ try {
200
+ const parsed = JSON.parse(data);
201
+ if (Number(parsed?.pid) !== process.pid) {
202
+ return; // Do not remove someone else's lock
203
+ }
204
+ }
205
+ catch {
206
+ // If unreadable, proceed to remove to avoid deadlocks
207
+ }
208
+ }
209
+ await fs.unlink(this.lockFile);
210
+ }
211
+ catch {
212
+ // Ignore errors during unlock (file may already be gone)
213
+ }
214
+ };
215
+ }
216
+ catch (error) {
217
+ const code = error.code;
218
+ if (code !== "EEXIST") {
219
+ // Unexpected error (permissions, disk full, etc.)
220
+ throw error;
221
+ }
222
+ // Lock exists - attempt safe stale-lock recovery
223
+ try {
224
+ const raw = await fs.readFile(this.lockFile, "utf-8");
225
+ const parsed = JSON.parse(raw || "{}");
226
+ const pid = Number(parsed?.pid);
227
+ const created = Number(parsed?.created);
228
+ const isOld = Number.isFinite(created) && Date.now() - created > maxLockAgeMs;
229
+ let pidAlive = true;
230
+ if (Number.isFinite(pid) && pid > 0) {
231
+ try {
232
+ // Signal 0 checks for existence of the process on UNIX-like systems
233
+ process.kill(pid, 0);
234
+ pidAlive = true;
235
+ }
236
+ catch {
237
+ pidAlive = false;
238
+ }
239
+ }
240
+ if (isOld || !pidAlive) {
241
+ // Safe to remove stale lock
242
+ await fs.unlink(this.lockFile).catch(() => { });
243
+ }
244
+ }
245
+ catch {
246
+ // If we can't read/parse, just continue with normal retry
247
+ }
248
+ // Wait and retry
249
+ if (attempt < maxAttempts - 1) {
250
+ await new Promise((resolve) => setTimeout(resolve, 50));
251
+ }
252
+ }
253
+ }
254
+ // Timeout - fail rather than force acquisition (safer)
255
+ throw new Error("Failed to acquire lock: another process is modifying keys. Please try again.");
256
+ }
257
+ /**
258
+ * Initialize the key manager
259
+ *
260
+ * Creates the configuration directory (mode 0o700) and loads existing metadata.
261
+ * If the metadata file doesn't exist, creates a new empty store.
262
+ * Uses idiomatic Node.js approach: try to read, handle ENOENT if file missing.
263
+ * This method must be called before any other key management operations.
264
+ *
265
+ * @throws {Error} If the configuration directory cannot be created or metadata is corrupt
266
+ */
267
+ async initialize() {
268
+ try {
269
+ await fs.mkdir(this.configDir, { recursive: true, mode: 0o700 });
270
+ // Try to load existing metadata, create new if doesn't exist
271
+ try {
272
+ await this.loadMetadata();
273
+ }
274
+ catch (error) {
275
+ if (error.code === "ENOENT") {
276
+ // File doesn't exist - create new store
277
+ this.store = {
278
+ keys: [],
279
+ version: 1,
280
+ };
281
+ await this.saveMetadata();
282
+ }
283
+ else {
284
+ // Re-throw other errors (corrupt JSON, permissions, etc.)
285
+ throw error;
286
+ }
287
+ }
288
+ // Validate and clean up any orphaned metadata
289
+ await this.validateAndCleanup();
290
+ }
291
+ catch (error) {
292
+ logger.error("Failed to initialize key manager", { error });
293
+ throw new Error(`Failed to initialize key manager: ${error instanceof Error ? error.message : String(error)}`);
294
+ }
295
+ }
296
+ /**
297
+ * Load metadata from disk (with locking)
298
+ */
299
+ async loadMetadata() {
300
+ const unlock = await this.acquireLock();
301
+ try {
302
+ const data = await fs.readFile(this.metadataFile, "utf-8");
303
+ this.store = JSON.parse(data);
304
+ }
305
+ finally {
306
+ await unlock();
307
+ }
308
+ }
309
+ /**
310
+ * Create a backup of the metadata file before destructive operations
311
+ */
312
+ async backupMetadata() {
313
+ try {
314
+ const backupFile = `${this.metadataFile}.backup`;
315
+ const data = await fs.readFile(this.metadataFile, "utf-8");
316
+ await fs.writeFile(backupFile, data, { mode: 0o600 });
317
+ }
318
+ catch (error) {
319
+ // If original file doesn't exist, that's ok - no backup needed
320
+ if (error.code !== "ENOENT") {
321
+ logger.warn("Failed to create metadata backup", { error });
322
+ }
323
+ }
324
+ }
325
+ /**
326
+ * Save metadata to disk (with locking to prevent concurrent modifications)
327
+ */
328
+ async saveMetadata() {
329
+ if (!this.store) {
330
+ throw new Error("Key store not initialized");
331
+ }
332
+ // If a save is already in progress, wait for it
333
+ if (this.saveLock) {
334
+ await this.saveLock;
335
+ }
336
+ // Acquire lock and save
337
+ this.saveLock = (async () => {
338
+ const unlock = await this.acquireLock();
339
+ try {
340
+ // Create backup before saving if file exists and has keys
341
+ try {
342
+ const existingData = await fs.readFile(this.metadataFile, "utf-8");
343
+ const existing = JSON.parse(existingData);
344
+ if (existing.keys && existing.keys.length > 0) {
345
+ // Only backup if we're about to overwrite a file with keys
346
+ await this.backupMetadata();
347
+ }
348
+ }
349
+ catch {
350
+ // Ignore errors reading existing file
351
+ }
352
+ await fs.writeFile(this.metadataFile, JSON.stringify(this.store, null, 2), { mode: 0o600 });
353
+ }
354
+ catch (error) {
355
+ logger.error("Failed to save key metadata", { error });
356
+ throw new Error(`Failed to save key metadata: ${error instanceof Error ? error.message : String(error)}`);
357
+ }
358
+ finally {
359
+ await unlock();
360
+ this.saveLock = null;
361
+ }
362
+ })();
363
+ await this.saveLock;
364
+ }
365
+ /**
366
+ * Generate a unique ID for a key
367
+ * Uses UUID v4 for guaranteed uniqueness and no collision risk
368
+ */
369
+ generateId() {
370
+ return randomUUID();
371
+ }
372
+ /**
373
+ * Validate API key format
374
+ */
375
+ validateApiKey(apiKey) {
376
+ if (!apiKey) {
377
+ throw new Error("API key is required");
378
+ }
379
+ if (!/^[a-f0-9]{32}$/.test(apiKey)) {
380
+ throw new Error("API key must be a 32-character lowercase hexadecimal string");
381
+ }
382
+ }
383
+ /**
384
+ * Validate and normalize base URL
385
+ */
386
+ validateBaseUrl(baseUrl) {
387
+ if (!baseUrl) {
388
+ throw new Error("Base URL is required");
389
+ }
390
+ let url;
391
+ try {
392
+ url = new URL(baseUrl);
393
+ }
394
+ catch (error) {
395
+ throw new Error(`Invalid base URL format: ${error instanceof Error ? error.message : String(error)}`);
396
+ }
397
+ // Require HTTPS for security, except allow http for localhost during local development
398
+ if (!isHttpsOrLocalhost(url)) {
399
+ throw new Error("Base URL must use HTTPS protocol for security. Insecure HTTP is only permitted for localhost.");
400
+ }
401
+ // Warn if not a standard Iterable domain
402
+ const isIterableDomain = url.hostname === "iterable.com" || url.hostname.endsWith(".iterable.com");
403
+ const isLocalhost = isLocalhostHost(url.hostname);
404
+ if (!isIterableDomain && !isLocalhost) {
405
+ logger.warn("Non-standard Iterable domain detected", {
406
+ hostname: url.hostname,
407
+ });
408
+ console.warn(`⚠️ Warning: Using non-standard Iterable domain: ${url.hostname}`);
409
+ }
410
+ }
411
+ /**
412
+ * Encrypt data using Windows DPAPI
413
+ *
414
+ * Security: Uses native C++ bindings to Windows DPAPI (Data Protection API).
415
+ * - No shell execution or code evaluation
416
+ * - Encrypted data can only be decrypted by the same user on the same machine
417
+ * - Uses CurrentUser scope for user-level protection
418
+ * - No optional entropy parameter (null) for simplicity
419
+ *
420
+ * @param text - Plain text to encrypt (validated as 32-char hex by caller)
421
+ * @returns Base64-encoded encrypted blob
422
+ * @throws {Error} If DPAPI encryption fails or platform is not Windows
423
+ */
424
+ async encryptWindows(text) {
425
+ if (process.platform !== "win32") {
426
+ throw new Error("DPAPI encryption is only supported on Windows");
427
+ }
428
+ try {
429
+ // Dynamic import to avoid loading native module on non-Windows platforms
430
+ const { Dpapi } = await import("@primno/dpapi");
431
+ // Convert string to buffer (UTF-8 encoding)
432
+ const plainBuffer = Buffer.from(text, "utf-8");
433
+ // Encrypt using DPAPI (returns Uint8Array)
434
+ // Parameters: data, optionalEntropy (null), scope ("CurrentUser")
435
+ const encryptedBytes = Dpapi.protectData(plainBuffer, null, "CurrentUser");
436
+ // Convert to Buffer and encode as base64 for JSON storage
437
+ return Buffer.from(encryptedBytes).toString("base64");
438
+ }
439
+ catch (error) {
440
+ logger.error("DPAPI encryption failed", { error });
441
+ throw new Error(`Failed to encrypt data with Windows DPAPI: ${error instanceof Error ? error.message : String(error)}`);
442
+ }
443
+ }
444
+ /**
445
+ * Decrypt data using Windows DPAPI
446
+ *
447
+ * Security: Uses native C++ bindings to Windows DPAPI.
448
+ * - No shell execution or code evaluation
449
+ * - Only the user who encrypted the data can decrypt it
450
+ * - Validates base64 input before decryption
451
+ *
452
+ * @param encryptedBase64 - Base64-encoded encrypted blob
453
+ * @returns Decrypted plain text
454
+ * @throws {Error} If DPAPI decryption fails, data is corrupt, or platform is not Windows
455
+ */
456
+ async decryptWindows(encryptedBase64) {
457
+ if (process.platform !== "win32") {
458
+ throw new Error("DPAPI decryption is only supported on Windows");
459
+ }
460
+ // Validate base64 format to prevent invalid data from reaching DPAPI
461
+ if (!/^[A-Za-z0-9+/]+=*$/.test(encryptedBase64)) {
462
+ throw new Error("Invalid encrypted data format (not valid base64)");
463
+ }
464
+ try {
465
+ // Dynamic import to avoid loading native module on non-Windows platforms
466
+ const { Dpapi } = await import("@primno/dpapi");
467
+ // Decode base64 to buffer
468
+ const encryptedBuffer = Buffer.from(encryptedBase64, "base64");
469
+ // Decrypt using DPAPI (returns Uint8Array)
470
+ // Parameters: encryptedData, optionalEntropy (null), scope ("CurrentUser")
471
+ const decryptedBytes = Dpapi.unprotectData(encryptedBuffer, null, "CurrentUser");
472
+ // Convert to Buffer and decode as UTF-8
473
+ return Buffer.from(decryptedBytes).toString("utf-8");
474
+ }
475
+ catch (error) {
476
+ logger.error("DPAPI decryption failed", { error });
477
+ throw new Error(`Failed to decrypt data with Windows DPAPI: ${error instanceof Error ? error.message : String(error)}`);
478
+ }
479
+ }
480
+ /**
481
+ * Save (add or update) an API key
482
+ *
483
+ * Private implementation that handles both add and update operations.
484
+ * If idOrName is provided, updates an existing key. Otherwise, adds a new key.
485
+ *
486
+ * @param name - User-friendly name for the key
487
+ * @param apiKey - The Iterable API key value
488
+ * @param baseUrl - Iterable API base URL
489
+ * @param envOverrides - Environment variable overrides. If undefined, keeps existing env (update) or no env (add). If provided (even empty {}), replaces/clears env.
490
+ * @param idOrName - If provided, updates the existing key with this ID or name
491
+ * @returns The ID of the saved key
492
+ */
493
+ async saveKey(name, apiKey, baseUrl, envOverrides, idOrName) {
494
+ if (!this.store) {
495
+ await this.initialize();
496
+ }
497
+ if (!this.store) {
498
+ throw new Error("Key store not initialized");
499
+ }
500
+ // Validate inputs
501
+ if (!name || name.trim().length === 0) {
502
+ throw new Error("Key name is required");
503
+ }
504
+ this.validateApiKey(apiKey);
505
+ this.validateBaseUrl(baseUrl);
506
+ const isUpdate = !!idOrName;
507
+ let existingIndex = -1;
508
+ let existingKey;
509
+ if (isUpdate) {
510
+ // UPDATE mode: Find existing key
511
+ existingIndex = this.store.keys.findIndex((k) => k.id === idOrName || k.name === idOrName);
512
+ if (existingIndex === -1) {
513
+ throw new Error(`Key not found: ${idOrName}`);
514
+ }
515
+ const keyAtIndex = this.store.keys[existingIndex];
516
+ if (!keyAtIndex) {
517
+ throw new Error(`Key not found: ${idOrName}`);
518
+ }
519
+ existingKey = keyAtIndex;
520
+ }
521
+ // Check for duplicate names
522
+ if (existingKey?.name !== name) {
523
+ if (this.store.keys.some((k) => k.name === name)) {
524
+ throw new Error(`Key with name "${name}" already exists`);
525
+ }
526
+ }
527
+ let metadata;
528
+ if (isUpdate) {
529
+ if (!existingKey) {
530
+ throw new Error(`Key not found: ${idOrName}`);
531
+ }
532
+ metadata = {
533
+ ...existingKey,
534
+ name,
535
+ baseUrl,
536
+ updated: new Date().toISOString(),
537
+ ...(envOverrides !== undefined ? { env: envOverrides } : {}),
538
+ };
539
+ }
540
+ else {
541
+ metadata = {
542
+ id: this.generateId(),
543
+ name,
544
+ baseUrl,
545
+ created: new Date().toISOString(),
546
+ isActive: this.store.keys.length === 0,
547
+ ...(envOverrides !== undefined ? { env: envOverrides } : {}),
548
+ };
549
+ }
550
+ // Store API key in secure storage
551
+ switch (this.storageMethod) {
552
+ case StorageMethod.KEYCHAIN:
553
+ try {
554
+ await this.execSecurity([
555
+ "add-generic-password",
556
+ "-a",
557
+ metadata.id,
558
+ "-s",
559
+ SERVICE_NAME,
560
+ "-w",
561
+ apiKey,
562
+ "-U",
563
+ ]);
564
+ }
565
+ catch (error) {
566
+ logger.error("Failed to store key in keychain", {
567
+ error,
568
+ id: metadata.id,
569
+ });
570
+ throw new Error(`Failed to store key in macOS Keychain: ${error instanceof Error ? error.message : String(error)}`);
571
+ }
572
+ break;
573
+ case StorageMethod.DPAPI:
574
+ try {
575
+ metadata.encryptedApiKey = await this.encryptWindows(apiKey);
576
+ }
577
+ catch (error) {
578
+ logger.error("Failed to encrypt key with DPAPI", {
579
+ error,
580
+ id: metadata.id,
581
+ });
582
+ throw new Error(`Failed to encrypt key with Windows DPAPI: ${error instanceof Error ? error.message : String(error)}`);
583
+ }
584
+ break;
585
+ default:
586
+ // Linux/Other: Store in JSON
587
+ metadata.apiKey = apiKey;
588
+ break;
589
+ }
590
+ // Store or update metadata
591
+ if (isUpdate) {
592
+ this.store.keys[existingIndex] = metadata;
593
+ }
594
+ else {
595
+ this.store.keys.push(metadata);
596
+ }
597
+ await this.saveMetadata();
598
+ logger.debug(isUpdate ? "API key updated" : "API key added", {
599
+ id: metadata.id,
600
+ name: metadata.name,
601
+ });
602
+ return metadata.id;
603
+ }
604
+ /**
605
+ * Add a new API key
606
+ *
607
+ * Stores an API key securely (Keychain on macOS, encrypted on Windows, file on Linux).
608
+ *
609
+ * @param name - User-friendly name for the key (must be unique)
610
+ * @param apiKey - 32-character lowercase hexadecimal Iterable API key
611
+ * @param baseUrl - Iterable API base URL (must be HTTPS)
612
+ * @param envOverrides - Optional environment variable overrides for this key
613
+ * @returns The unique ID generated for this key
614
+ * @throws {Error} If the key name already exists, validation fails, or storage fails
615
+ */
616
+ async addKey(name, apiKey, baseUrl, envOverrides) {
617
+ return this.saveKey(name, apiKey, baseUrl, envOverrides);
618
+ }
619
+ /**
620
+ * Update an existing API key
621
+ *
622
+ * Updates all properties of an existing key including name, API key value, base URL, and environment overrides.
623
+ *
624
+ * @param idOrName - The unique ID or name of the key to update
625
+ * @param name - New name for the key (must be unique unless unchanged)
626
+ * @param apiKey - New API key value (can be the same as existing)
627
+ * @param baseUrl - New Iterable API base URL
628
+ * @param envOverrides - New environment variable overrides (undefined = keep existing, {} = clear)
629
+ * @returns The ID of the updated key
630
+ * @throws {Error} If the key is not found, name conflict, validation fails, or storage fails
631
+ */
632
+ async updateKey(idOrName, name, apiKey, baseUrl, envOverrides) {
633
+ return this.saveKey(name, apiKey, baseUrl, envOverrides, idOrName);
634
+ }
635
+ /**
636
+ * List all keys (metadata only, not the actual keys)
637
+ *
638
+ * Returns metadata for all stored API keys including names, IDs, base URLs,
639
+ * timestamps, and active status. Does NOT return the actual API key values.
640
+ *
641
+ * @returns Array of API key metadata
642
+ * @throws {Error} If the key store is not initialized
643
+ */
644
+ async listKeys() {
645
+ if (!this.store) {
646
+ await this.initialize();
647
+ }
648
+ if (!this.store) {
649
+ throw new Error("Key store not initialized");
650
+ }
651
+ return [...this.store.keys];
652
+ }
653
+ /**
654
+ * Get key metadata by ID
655
+ *
656
+ * @param idOrName - The unique ID or user-friendly name of the key
657
+ * @returns The key metadata, or null if not found
658
+ */
659
+ async getKeyMetadata(idOrName) {
660
+ const keys = await this.listKeys();
661
+ return keys.find((k) => k.id === idOrName || k.name === idOrName) ?? null;
662
+ }
663
+ /**
664
+ * Get a key by ID or name
665
+ *
666
+ * Retrieves the actual API key value from storage.
667
+ *
668
+ * @param idOrName - The unique ID or user-friendly name of the key
669
+ * @returns The API key value, or null if not found
670
+ * @throws {Error} If storage access fails
671
+ */
672
+ async getKey(idOrName) {
673
+ if (!this.store) {
674
+ await this.initialize();
675
+ }
676
+ if (!this.store) {
677
+ throw new Error("Key store not initialized");
678
+ }
679
+ // Find the key metadata
680
+ const keyMeta = this.store.keys.find((k) => k.id === idOrName || k.name === idOrName);
681
+ if (!keyMeta) {
682
+ return null;
683
+ }
684
+ // Get API key
685
+ switch (this.storageMethod) {
686
+ case StorageMethod.KEYCHAIN:
687
+ // macOS: Get from Keychain
688
+ try {
689
+ const apiKey = await this.execSecurity([
690
+ "find-generic-password",
691
+ "-a",
692
+ keyMeta.id,
693
+ "-s",
694
+ SERVICE_NAME,
695
+ "-w",
696
+ ]);
697
+ if (!apiKey) {
698
+ logger.error("Key not found in keychain", { id: keyMeta.id });
699
+ throw new Error(`Key not found in macOS Keychain for ID ${keyMeta.id}`);
700
+ }
701
+ return apiKey;
702
+ }
703
+ catch (error) {
704
+ logger.error("Failed to retrieve key from keychain", {
705
+ error,
706
+ id: keyMeta.id,
707
+ });
708
+ throw new Error(`Failed to retrieve key from macOS Keychain: ${error instanceof Error ? error.message : String(error)}`);
709
+ }
710
+ case StorageMethod.DPAPI:
711
+ // Windows: Decrypt from JSON
712
+ if (keyMeta.encryptedApiKey) {
713
+ try {
714
+ return await this.decryptWindows(keyMeta.encryptedApiKey);
715
+ }
716
+ catch (error) {
717
+ logger.error("Failed to decrypt key with DPAPI", {
718
+ error,
719
+ id: keyMeta.id,
720
+ });
721
+ throw new Error(`Failed to decrypt key with Windows DPAPI: ${error instanceof Error ? error.message : String(error)}`);
722
+ }
723
+ }
724
+ else if (keyMeta.apiKey) {
725
+ // Fallback for legacy keys stored in plaintext on Windows
726
+ return keyMeta.apiKey;
727
+ }
728
+ else {
729
+ logger.error("Key not found in metadata", { id: keyMeta.id });
730
+ throw new Error(`Key not found in storage for ID ${keyMeta.id}`);
731
+ }
732
+ default:
733
+ // Linux/Other: Get from JSON
734
+ if (!keyMeta.apiKey) {
735
+ logger.error("Key not found in metadata", { id: keyMeta.id });
736
+ throw new Error(`Key not found in storage for ID ${keyMeta.id}`);
737
+ }
738
+ return keyMeta.apiKey;
739
+ }
740
+ }
741
+ /**
742
+ * Get the currently active key
743
+ *
744
+ * Retrieves the API key value for whichever key is currently marked as active.
745
+ * Only one key can be active at a time.
746
+ *
747
+ * @returns The active API key value, or null if no key is active
748
+ * @throws {Error} If storage access fails
749
+ */
750
+ async getActiveKey() {
751
+ if (!this.store) {
752
+ await this.initialize();
753
+ }
754
+ if (!this.store) {
755
+ throw new Error("Key store not initialized");
756
+ }
757
+ const activeKey = this.store.keys.find((k) => k.isActive);
758
+ if (!activeKey) {
759
+ return null;
760
+ }
761
+ return this.getKey(activeKey.id);
762
+ }
763
+ /**
764
+ * Get the active key metadata
765
+ *
766
+ * Returns metadata for the currently active key without retrieving the
767
+ * actual API key value.
768
+ *
769
+ * @returns The active key metadata, or null if no key is active
770
+ * @throws {Error} If the key store is not initialized
771
+ */
772
+ async getActiveKeyMetadata() {
773
+ if (!this.store) {
774
+ await this.initialize();
775
+ }
776
+ if (!this.store) {
777
+ throw new Error("Key store not initialized");
778
+ }
779
+ return this.store.keys.find((k) => k.isActive) || null;
780
+ }
781
+ /**
782
+ * Set a key as active by ID or name
783
+ *
784
+ * Marks the specified key as active and deactivates all other keys.
785
+ * The active key's base URL and API key will be used by the CLI.
786
+ * Only one key can be active at a time.
787
+ *
788
+ * @param idOrName - The unique ID or user-friendly name of the key to activate
789
+ * @throws {Error} If the key is not found or the store is not initialized
790
+ */
791
+ async setActiveKey(idOrName) {
792
+ if (!this.store) {
793
+ await this.initialize();
794
+ }
795
+ if (!this.store) {
796
+ throw new Error("Key store not initialized");
797
+ }
798
+ // Find the key
799
+ const keyMeta = this.store.keys.find((k) => k.id === idOrName || k.name === idOrName);
800
+ if (!keyMeta) {
801
+ throw new Error(`Key not found: ${idOrName}`);
802
+ }
803
+ // Deactivate all keys
804
+ this.store.keys.forEach((k) => {
805
+ k.isActive = false;
806
+ });
807
+ // Activate the selected key
808
+ keyMeta.isActive = true;
809
+ await this.saveMetadata();
810
+ }
811
+ /**
812
+ * Delete a key by ID
813
+ *
814
+ * Removes a key from storage and the metadata store.
815
+ * Note: Only accepts key ID (not name) since IDs are guaranteed unique.
816
+ *
817
+ * @param id - The unique ID of the key to delete
818
+ * @throws {Error} If the key is not found or the store is not initialized
819
+ */
820
+ async deleteKey(id) {
821
+ if (!this.store) {
822
+ await this.initialize();
823
+ }
824
+ if (!this.store) {
825
+ throw new Error("Key store not initialized");
826
+ }
827
+ // Find the key by ID only
828
+ const index = this.store.keys.findIndex((k) => k.id === id);
829
+ if (index === -1) {
830
+ throw new Error(`Key not found with ID: ${id}`);
831
+ }
832
+ const keyMeta = this.store.keys[index];
833
+ if (!keyMeta) {
834
+ throw new Error(`Key not found with ID: ${id}`);
835
+ }
836
+ // Delete from storage
837
+ if (this.storageMethod === StorageMethod.KEYCHAIN) {
838
+ // macOS: Delete from Keychain
839
+ let keychainDeleted = false;
840
+ try {
841
+ await this.execSecurity([
842
+ "delete-generic-password",
843
+ "-a",
844
+ keyMeta.id,
845
+ "-s",
846
+ SERVICE_NAME,
847
+ ]);
848
+ keychainDeleted = true;
849
+ }
850
+ catch (error) {
851
+ logger.error("Failed to delete key from keychain", {
852
+ error,
853
+ id: keyMeta.id,
854
+ });
855
+ // Continue anyway to clean up metadata, but warn the user
856
+ }
857
+ // Remove from metadata
858
+ this.store.keys.splice(index, 1);
859
+ await this.saveMetadata();
860
+ if (!keychainDeleted) {
861
+ logger.warn("Key removed from metadata but may still exist in Keychain", {
862
+ id: keyMeta.id,
863
+ name: keyMeta.name,
864
+ });
865
+ console.warn(`⚠️ Warning: Key "${keyMeta.name}" removed from metadata but may still exist in Keychain.`);
866
+ console.warn(` To manually remove: security delete-generic-password -a "${keyMeta.id}" -s "${SERVICE_NAME}"`);
867
+ }
868
+ else {
869
+ logger.debug("API key deleted", { id: keyMeta.id, name: keyMeta.name });
870
+ }
871
+ }
872
+ else {
873
+ // Windows/Linux: Just remove from JSON
874
+ this.store.keys.splice(index, 1);
875
+ await this.saveMetadata();
876
+ logger.debug("API key deleted", { id: keyMeta.id, name: keyMeta.name });
877
+ }
878
+ }
879
+ /**
880
+ * Check if any keys exist
881
+ *
882
+ * Returns true if at least one API key has been stored in the key manager.
883
+ *
884
+ * @returns True if keys exist, false otherwise
885
+ * @throws {Error} If the key store is not initialized
886
+ */
887
+ async hasKeys() {
888
+ if (!this.store) {
889
+ await this.initialize();
890
+ }
891
+ if (!this.store) {
892
+ throw new Error("Key store not initialized");
893
+ }
894
+ return this.store.keys.length > 0;
895
+ }
896
+ /** Check if any key is currently marked as active (metadata only, no keychain call). */
897
+ async hasActiveKey() {
898
+ if (!this.store) {
899
+ await this.initialize();
900
+ }
901
+ if (!this.store) {
902
+ throw new Error("Key store not initialized");
903
+ }
904
+ return this.store.keys.some((k) => k.isActive);
905
+ }
906
+ /** Deactivate all keys (no key will be active). */
907
+ async deactivateAllKeys() {
908
+ if (!this.store) {
909
+ throw new Error("Key store not initialized");
910
+ }
911
+ this.store.keys.forEach((k) => {
912
+ k.isActive = false;
913
+ });
914
+ await this.saveMetadata();
915
+ }
916
+ /**
917
+ * Find a key by its actual API key value
918
+ *
919
+ * Checks all stored keys to see if the given API key value already exists.
920
+ * Useful for preventing duplicate key values.
921
+ *
922
+ * @param apiKeyValue - The API key value to search for
923
+ * @returns The metadata of the matching key, or null if not found
924
+ * @throws {Error} If storage access fails
925
+ */
926
+ async findKeyByValue(apiKeyValue) {
927
+ if (!this.store) {
928
+ await this.initialize();
929
+ }
930
+ if (!this.store) {
931
+ throw new Error("Key store not initialized");
932
+ }
933
+ // Check each key to see if the value matches
934
+ for (const keyMeta of this.store.keys) {
935
+ try {
936
+ let storedKey;
937
+ switch (this.storageMethod) {
938
+ case StorageMethod.KEYCHAIN:
939
+ storedKey = await this.execSecurity([
940
+ "find-generic-password",
941
+ "-a",
942
+ keyMeta.id,
943
+ "-s",
944
+ SERVICE_NAME,
945
+ "-w",
946
+ ]);
947
+ break;
948
+ case StorageMethod.DPAPI:
949
+ if (keyMeta.encryptedApiKey) {
950
+ storedKey = await this.decryptWindows(keyMeta.encryptedApiKey);
951
+ }
952
+ else if (keyMeta.apiKey) {
953
+ storedKey = keyMeta.apiKey;
954
+ }
955
+ else {
956
+ continue;
957
+ }
958
+ break;
959
+ default:
960
+ if (!keyMeta.apiKey) {
961
+ continue;
962
+ }
963
+ storedKey = keyMeta.apiKey;
964
+ break;
965
+ }
966
+ if (storedKey.trim() === apiKeyValue) {
967
+ return keyMeta;
968
+ }
969
+ }
970
+ catch {
971
+ // Skip keys that can't be retrieved (may have been manually deleted)
972
+ continue;
973
+ }
974
+ }
975
+ return null;
976
+ }
977
+ }
978
+ // Singleton instance
979
+ let keyManagerInstance = null;
980
+ /**
981
+ * Get the singleton KeyManager instance
982
+ */
983
+ export function getKeyManager() {
984
+ if (!keyManagerInstance) {
985
+ keyManagerInstance = new KeyManager();
986
+ }
987
+ return keyManagerInstance;
988
+ }
989
+ //# sourceMappingURL=key-manager.js.map